Цель: \ Исследовать рынок общественного питания Москвы, для выбора места будущего проекта инвесторов.

Задачи:

  1. Изучить и проверить данные;
  2. Найти интересные особенности;
  3. Подготовить презентацию с полученными результатами.

Анализ рынка заведений общественного питания Москвы

  • 1  Загрузка файл с данными и изучение общей информации
  • 2  Предобработка данных
    • 2.1  Работа с неявными дубликатами
    • 2.2  Работа с пропусками в столбцах
    • 2.3  Добавление столбцов
  • 3  Анализ данных:
    • 3.1  по категорий заведений
    • 3.2  по количеству посадочных мест по категориям
    • 3.3  по сетевым и несетевым заведениям
    • 3.4  по названиям заведений и ТОР-15 популярных сетей
    • 3.5  по административным районам
    • 3.6  распределения средних рейтингов по категориям заведений
    • 3.7  по среднему рейтингу заведений по районам
    • 3.8  TOP-15 улиц по количеству заведений
    • 3.9  по улицам, на которых находится только один объект общепита
    • 3.10  по медиане средних чеков по районам
    • 3.11  взаимосвязи по часам работы заведений
    • 3.12  распределения заведений с плохим рейтингом
    • 3.13  Вывод по анализу данных
  • 4  Детализация исследования
    • 4.1  Оптимальные критерии для запуска проекта - кофейня в стиле «Central Perk»
In [1]:
# подключаю библиотеки
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import seaborn as sns
import plotly.express as px
from plotly import graph_objects as go
import json
import folium
from folium import Map, Choropleth, Marker, plugins
from folium.plugins import MarkerCluster
from folium.plugins.heat_map import HeatMap
from folium.features import CustomIcon
import warnings
warnings.filterwarnings('ignore')
In [2]:
# создаю функцию для просмотра датасета
def first_view(x):
    print('-' * 50, '\n', 'Исходный датафрейм:', '\n', '-'*50)
    display(x.head())
    print('-' * 50, '\n', 'Общая информация о датафрейме:', '\n', '-'*50)
    display(x.info())
    print('-' * 50, '\n', 'Количество пустых значений в датафрейме:', '\n', '-'*50)
    display(x.isna().sum())
    print('-' * 50,'\n','Количество явных дубликатов в датафрейме:','\n','-'*50)
    display(x.duplicated().sum())
    print('-' * 50,'\n','Названия столбцов:','\n','-'*50)
    display(x.columns)
In [3]:
# создаю функцию для акронимов округов
def make_acronym(phrase):
    phrase = phrase.replace('-', ' ').split()
    acronym = ""
    for word in phrase:
        acronym = acronym + word[0].upper()
    return acronym
In [4]:
# создаю функцию для визуализации фоновой картограммы через бибилиотеку plotly
def choropleth_mapbox(df, color, locations, geojson, labels,
                      featureidkey='properties.name',
                      center={'lat': 55.751244, 'lon': 37.618423},
                      color_continuous_scale='YlGnBu',
                      range_color=(450, 1000),
                      zoom=9, opacity=0.7, height=500):

    fig = px.choropleth_mapbox(df,
                               color=color,
                               locations=locations,
                               geojson=geojson,
                               labels=labels,
                               featureidkey=featureidkey,
                               center=center,
                               color_continuous_scale=color_continuous_scale,
                               range_color=range_color,
                               zoom=zoom, opacity=opacity
                               )
    fig.update_geos(fitbounds='locations')
    fig.update_layout(
        margin={'r': 0, 't': 0, 'l': 0, 'b': 0},
        height=height,
        mapbox_style='carto-positron'
    )
    return fig

Загрузка файл с данными и изучение общей информации¶

Применю функцию с набором методов для просмотра сводной информации.

In [5]:
# отображение всех колонок и строк таблиц
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', None)
# ширина колонок при выводе
pd.set_option('display.max_colwidth', 1000)
In [6]:
# загружаю датасет
data = pd.read_csv('moscow_places.csv')
# прочитаю файл в JSON-формате и сохраню в переменную
with open('admin_level_geomap.geojson', 'r', encoding='utf8') as f:
    geo_json = json.load(f)
In [7]:
first_view(data)
-------------------------------------------------- 
 Исходный датафрейм: 
 --------------------------------------------------
name category address district hours lat lng rating price avg_bill middle_avg_bill middle_coffee_cup chain seats
0 WoWфли кафе Москва, улица Дыбенко, 7/1 Северный административный округ ежедневно, 10:00–22:00 55.878494 37.478860 5.0 NaN NaN NaN NaN 0 NaN
1 Четыре комнаты ресторан Москва, улица Дыбенко, 36, корп. 1 Северный административный округ ежедневно, 10:00–22:00 55.875801 37.484479 4.5 выше среднего Средний счёт:1500–1600 ₽ 1550.0 NaN 0 4.0
2 Хазри кафе Москва, Клязьминская улица, 15 Северный административный округ пн-чт 11:00–02:00; пт,сб 11:00–05:00; вс 11:00–02:00 55.889146 37.525901 4.6 средние Средний счёт:от 1000 ₽ 1000.0 NaN 0 45.0
3 Dormouse Coffee Shop кофейня Москва, улица Маршала Федоренко, 12 Северный административный округ ежедневно, 09:00–22:00 55.881608 37.488860 5.0 NaN Цена чашки капучино:155–185 ₽ NaN 170.0 0 NaN
4 Иль Марко пиццерия Москва, Правобережная улица, 1Б Северный административный округ ежедневно, 10:00–22:00 55.881166 37.449357 5.0 средние Средний счёт:400–600 ₽ 500.0 NaN 1 148.0
-------------------------------------------------- 
 Общая информация о датафрейме: 
 --------------------------------------------------
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 8406 entries, 0 to 8405
Data columns (total 14 columns):
 #   Column             Non-Null Count  Dtype  
---  ------             --------------  -----  
 0   name               8406 non-null   object 
 1   category           8406 non-null   object 
 2   address            8406 non-null   object 
 3   district           8406 non-null   object 
 4   hours              7870 non-null   object 
 5   lat                8406 non-null   float64
 6   lng                8406 non-null   float64
 7   rating             8406 non-null   float64
 8   price              3315 non-null   object 
 9   avg_bill           3816 non-null   object 
 10  middle_avg_bill    3149 non-null   float64
 11  middle_coffee_cup  535 non-null    float64
 12  chain              8406 non-null   int64  
 13  seats              4795 non-null   float64
dtypes: float64(6), int64(1), object(7)
memory usage: 919.5+ KB
None
-------------------------------------------------- 
 Количество пустых значений в датафрейме: 
 --------------------------------------------------
name                    0
category                0
address                 0
district                0
hours                 536
lat                     0
lng                     0
rating                  0
price                5091
avg_bill             4590
middle_avg_bill      5257
middle_coffee_cup    7871
chain                   0
seats                3611
dtype: int64
-------------------------------------------------- 
 Количество явных дубликатов в датафрейме: 
 --------------------------------------------------
0
-------------------------------------------------- 
 Названия столбцов: 
 --------------------------------------------------
Index(['name', 'category', 'address', 'district', 'hours', 'lat', 'lng',
       'rating', 'price', 'avg_bill', 'middle_avg_bill', 'middle_coffee_cup',
       'chain', 'seats'],
      dtype='object')

Вывод: \ Датафрейм состооит из 14 столбцов и 8 406 строк. \ В датасете выявлены пропуски в ряде столбцов:

  • hours - пропуски в количестве 536;
  • price - пропуски в количестве 5 091;
  • avg_bill - пропуски в количестве 4 590;
  • middle_avg_bill - пропуски в количестве 5 257;
  • middle_coffee_cup - пропуски в количестве 7 871;
  • seats - пропуски в количестве 3 611.

Работу по замене пропусков буду производить на этапе предобработки. Количество явных дубликатов - 0. Названия столбцов не требуют преобразования.

Предобработка данных¶

Работа с неявными дубликатами¶

Проверю на наличие неявных дубликатов в столбце name и сразу же приведу их к нижнему регистру.

In [8]:
print('Количество уникальных названий заведений в текущем регистре:',
      data['name'].nunique())
data['name'] = data['name'].str.lower()
print('Количество уникальных названий заведений после изменения на нижний регистр:',
      data['name'].nunique())
print('Количество явных дубликатов после изменения регистра названия заведения:',
      data.duplicated().sum())
Количество уникальных названий заведений в текущем регистре: 5614
Количество уникальных названий заведений после изменения на нижний регистр: 5512
Количество явных дубликатов после изменения регистра названия заведения: 0

Посмотрю, есть ли неявные дубликаты при комбинации столбцов name и address, а затем по меткам геоданных.

In [9]:
print('Количество неявных дубликатов по названию и адресу заведения:',
      data[['name', 'address']].duplicated().sum())
print('Количество неявных дубликатов по геоданным:',
      data[['lat', 'lng']].duplicated().sum())
Количество неявных дубликатов по названию и адресу заведения: 3
Количество неявных дубликатов по геоданным: 34

Дубликаты по столбцам name и address сохраню в список, чтобы проверить, действительно ли это ошибки в датасете или это представители сетевых заведений.

In [10]:
dup_name = list(data[data[['name', 'address']].duplicated()]['name'])
print('Названия заведений с неявными дубликатами:', dup_name)
Названия заведений с неявными дубликатами: ['more poke', 'раковарня клешни и хвосты', 'хлеб да выпечка']
In [11]:
dup_index = data.query('name in @dup_name').sort_values(by=['name', 'chain'])
dup_index[['name', 'address', 'chain']]
Out[11]:
name address chain
1430 more poke Москва, Волоколамское шоссе, 11, стр. 2 0
1511 more poke Москва, Волоколамское шоссе, 11, стр. 2 1
6088 more poke Москва, Духовской переулок, 19 1
2211 раковарня клешни и хвосты Москва, проспект Мира, 118 0
2420 раковарня клешни и хвосты Москва, проспект Мира, 118 1
7270 раковарня клешни и хвосты Москва, Братиславская улица, 12 1
3109 хлеб да выпечка Москва, Ярцевская улица, 19 0
3091 хлеб да выпечка Москва, Ярцевская улица, 19 1
7937 хлеб да выпечка Москва, Каширское шоссе, 61Г 1
In [12]:
dup_index = dup_index[dup_index['chain'] == 0].index.to_list()
print('Индексы дубликатов:', dup_index)
Индексы дубликатов: [1430, 2211, 3109]

Действительно, в данных выявлены неявные дубликаты, которые нужно удалить. Исключать из датасета буду строки с данными, где в графе сетевое заведение или нет указан 0, так как эти данные являются ложными.

In [13]:
for i in dup_index:
    data = data.drop(index=[i])
In [14]:
print('Количество неявных дубликатов:',
      data[data[['name', 'address']].duplicated()]['name'].sum())
Количество неявных дубликатов: 0
In [15]:
data[data[['lat', 'lng']].duplicated(keep=False)].sort_values(by='lat').head(6)
Out[15]:
name category address district hours lat lng rating price avg_bill middle_avg_bill middle_coffee_cup chain seats
7640 крошка картошка быстрое питание Москва, Новоясеневский проспект, 7 Юго-Западный административный округ ежедневно, 10:00–22:00 55.607489 37.532367 4.1 NaN NaN NaN NaN 1 60.0
7638 кофешефф ресторан Москва, Новоясеневский проспект, 7 Юго-Западный административный округ ежедневно, 08:30–21:00 55.607489 37.532367 4.3 NaN NaN NaN NaN 0 60.0
7787 тануки ресторан Москва, Липецкая улица, 2, корп. 8 Южный административный округ ежедневно, 10:00–05:00 55.608307 37.664941 4.3 выше среднего Средний счёт:1000–1500 ₽ 1250.0 NaN 1 120.0
7781 ёрш пиццерия Москва, Липецкая улица, 2, корп. 8 Южный административный округ ежедневно, 11:30–05:00 55.608307 37.664941 4.4 выше среднего Средний счёт:1000–1500 ₽ 1250.0 NaN 1 120.0
7767 за обе щёки кафе Москва, Варшавское шоссе, вл132/2 Южный административный округ ежедневно, 09:00–21:00 55.620316 37.608922 3.6 NaN NaN NaN NaN 1 NaN
7660 100лоффка столовая Москва, Варшавское шоссе, вл132/2 Южный административный округ пн-пт 09:00–17:30 55.620316 37.608922 4.4 низкие Средний счёт:100–270 ₽ 185.0 NaN 0 NaN

По визуальной проверке совпадений по геоданным видно, что все заведения разные, не смотря на одинаковые координаты. Вероятно, произошел сбой при сборе или ошибка при определении локации.

Работа с пропусками в столбцах¶

Пропущенные значения в столбцах price, avg_bill, middle_avg_bill, middle_coffee_cup, seats заполнить корректно не представляется возможным. Оставлю как есть. Тип данных в столбце seats логично преобразовать в int, но так как он содержит пропуски это действие не смогу провести.

Добавление столбцов¶

In [16]:
data['street'] = data['address'].str.split(pat=",", expand=True)[1]
data['is_24_7'] = data['hours'].apply(lambda x: True if x == 'ежедневно, круглосуточно' else False)
data['district_acronym'] = data['district'].apply(make_acronym)
data[['street', 'is_24_7', 'district_acronym']].head(11)
Out[16]:
street is_24_7 district_acronym
0 улица Дыбенко False САО
1 улица Дыбенко False САО
2 Клязьминская улица False САО
3 улица Маршала Федоренко False САО
4 Правобережная улица False САО
5 Ижорская улица False САО
6 Клязьминская улица False САО
7 Клязьминская улица False САО
8 Дмитровское шоссе False САО
9 Ангарская улица False САО
10 Левобережная улица True САО

Добавил новый столбец street с названиями улиц из столбца address, столбец is_24/7 с обозначением, что заведение работает ежедневно и круглосуточно (24/7) со значениями True — если заведение работает ежедневно и круглосуточно, False — в противоположном случае, и столбец district_acronym — для удобства вывода названий административных округов и визуализации в формате акронима.

Вывод:

  • нашел и обработал неявных дубликатов;
  • столбец с названиями привел к нижнему регистру;
  • добавил столбцы с названиями улиц, с маркером круглосуточной работы и с обозначениями акронимов.

Анализ данных:¶

по категорий заведений¶

Исследую количество объектов общественного питания Москвы по категориям и построю визуализацию методом столбчатой диаграммы.

In [17]:
# подготавливаю датасет для визуализации
data_category = data.groupby('category').agg(total_counts=('category', 'count'))
data_category.sort_values(by='total_counts', ascending=False, inplace=True)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
    x=data_category.index,
    y='total_counts',
    data=data_category
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
    ax.annotate(
        format(int(p.get_height()), ',').replace(',', ' '),
        (p.get_x() + p.get_width() / 2., p.get_height()),
        ha = 'center', va = 'center',
        xytext = (0, -14), color='white',
        textcoords = 'offset points', size=16)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Количество объектов общественного питания по категориям', size=18)
plt.xlabel('Категория заведения', size=14)
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
# сводная таблица по долям
display((data_category['total_counts'] / data_category['total_counts'].sum() * 100).round(2).to_frame().T)
category кафе ресторан кофейня бар,паб пиццерия быстрое питание столовая булочная
total_counts 28.29 24.29 16.82 9.1 7.53 7.18 3.75 3.05

Вывод: \ Категории заведений отличаются по количеству. Самым частым типом является кафе, немного уступает ресторан, третье место занимает кофейня. Самым редким типом заведения является булочная. Удивительно то, что категория быстрое питание не вошла в тройку лидеров, а находится в конце списка.

по количеству посадочных мест по категориям¶

Выведу сводную информацию по значениям столбца seats в разбивке по категориям заведений и медианное значение количества посадочных мест среди всех категорий. Визуализирую через диаграммы размаха и столбчатую, и дополнительно построю тепловую карту с распределениями медианных значений категорий заведений по округам.

In [18]:
display(
    data.groupby('category')['seats']
        .describe()
        .sort_values(by='count', ascending=False).T
)
category ресторан кафе кофейня бар,паб пиццерия быстрое питание столовая булочная
count 1268.000000 1217.000000 751.000000 468.000000 427.000000 349.000000 164.000000 148.000000
mean 121.869874 97.365653 111.199734 124.532051 94.496487 98.891117 99.750000 89.385135
std 123.838539 117.922464 127.837772 145.011574 112.282703 106.611739 122.951453 97.685844
min 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
25% 48.000000 35.000000 40.000000 48.000000 30.000000 28.000000 40.000000 25.000000
50% 86.000000 60.000000 80.000000 82.500000 55.000000 65.000000 75.500000 50.000000
75% 150.000000 120.000000 144.000000 150.000000 120.000000 140.000000 117.000000 120.000000
max 1288.000000 1288.000000 1288.000000 1288.000000 1288.000000 1040.000000 1200.000000 625.000000
In [19]:
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.boxplot(x='seats', y='category', data=data)
plt.yticks(rotation=30, size=12)
plt.grid(axis='x')
# указываю названия графика и осей, вывожу на экран
plt.title('Диаграмма размаха по количество посадочных мест по категориям заведений', size=18)
plt.xlabel('Количеств мест', size=14)
plt.ylabel('Категория', size=14)
plt.show()
In [20]:
data[data['seats'] == 1288]['address'].unique()
Out[20]:
array(['Москва, проспект Вернадского, 94, корп. 1',
       'Москва, проспект Вернадского, 121, корп. 1',
       'Москва, проспект Вернадского, 97, корп. 1',
       'Москва, проспект Вернадского, 84, стр. 1',
       'Москва, проспект Вернадского, 51, стр. 1',
       'Москва, проспект Вернадского, 41, стр. 1'], dtype=object)

Посмотрел, где распологаются заведения с максимальным количеством мест (1288) - все на проспекте Вернадского.

In [21]:
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.boxplot(x='seats', y='category', data=data)
plt.yticks(rotation=30, size=12)
plt.grid(axis='x')
# увеличиваю масштаб
plt.xlim(-5, 350)
# обозначаю общее медианное значение
plt.axvline(x=data['seats'].median(), color='white', linestyle='--')
# указываю названия графика и осей, вывожу на экран
plt.title('Диаграмма размаха по количество посадочных мест по категориям заведений', size=18)
plt.xlabel('Количеств мест', size=14)
plt.ylabel('Категория', size=14)
plt.show()
In [22]:
# подготавливаю датасет для визуализации
data_seats = (
    data.groupby('category')
        .agg(avg_seats=('seats', 'median'))
        .sort_values(by='avg_seats', ascending=False)
)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
    x=data_seats.index,
    y="avg_seats",
    data=data_seats
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
    ax.annotate(
        format(int(p.get_height()), ',').replace(',', ' '),
        (p.get_x() + p.get_width() / 2., p.get_height()),
        ha = 'center', va = 'center',
        xytext = (0, -14), color='white',
        textcoords = 'offset points', size=16)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Медианное значение количества посадочных мест по категориям заведений', size=18)
plt.xlabel('Категория заведения', size=14)
plt.ylabel('Количество посадочных мест', size=14)
sns.despine(left=True)
plt.show()
In [23]:
# подготавливаю датасет для визуализации
pvt_data_seats = data.pivot_table(
    index='district_acronym', columns='category',
    values='seats', aggfunc='median'
)
# строю визуализацию
fig, ax = plt.subplots(figsize=(11, 9))
sns.heatmap(
    pvt_data_seats,
    cmap='Reds', center=75, annot=True,
    fmt=".1f", linewidths=.5, ax=ax)
# указываю название графика
ax.set_title('Медиа количества посадочных мест в каждой категории по районам', size=16)
plt.ylabel(' ')
plt.xlabel(' ')
plt.setp(ax.get_xticklabels(), rotation=30)
plt.setp(ax.get_yticklabels(), rotation=0)
# вывожу на экран
fig.tight_layout()
plt.show()
print(
    'Медианное значение количества посадочных мест среди всех категорий заведений: {:.0f}'
      .format(data['seats'].median())
)
Медианное значение количества посадочных мест среди всех категорий заведений: 75

Вывод: \ На диаграммах размаха видно, что по всем категориям наблюдаются выбросы. Возможно, что в выборку попали заведения, которые располагаются на фуд-кортах торговых центрах и в качестве своей вместимости указали вместимость общую. \ Медианное значение для всех составляет 75 мест. Больше этого числа имеют ресторан (86), кофейня (80) и бар.паб (82). Самая маленькая медиана мест - в булочной (50). Среди округов выше медианного значений почти во всех категориях у ЦАО (7 из 8), а у ВАО, САО и ЮЗАО только по одной из категорий заведений медиана превышена.

по сетевым и несетевым заведениям¶

Рассмотрю и изображу соотношение сетевых и несетевых заведений в датасете. Для визуализации использую круговую и столбчатую диаграммы с разбивкой на два критерия.

In [24]:
# строю и вывожу на экран визуализацию
fig = go.Figure(
    data = go.Pie(labels = data['chain'].value_counts().reset_index(),
                  values = data['chain'].value_counts()),
    layout = go.Layout(title = go.layout.Title(text = \
                  "Соотношение сетевых и несетевых заведений"),
                  height = 500, width = 800, title_x=0.5)
)
for trace in fig.data:
    trace['labels'] = ['Несетевые', 'Сетевые']
fig.show()

Почти две трети являются несетевыми заведениям. Посмотрю, как по категориям делится это значение.

In [25]:
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.histplot(
    data=data, x='category',
    hue='chain', multiple='dodge',
    palette='Set1', shrink=.9
)
plt.grid(axis='y')
ax.legend(['Сетевые заведения', 'Несетевые заведения'])
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
    ax.annotate(
        format(int(p.get_height()), ',').replace(',', ' '),
        (p.get_x() + p.get_width() / 2., p.get_height()),
        ha = 'center', va = 'center',
        xytext = (0, -10), color='white',
        textcoords = 'offset points', size=12)
# указываю названия графика и осей, вывожу на экран
plt.title('Количество сетевых и несетевых заведений общественного питания по категориям', size=17)
plt.ylabel('Количество', size=14)
plt.xlabel('Категория', size=14)
plt.show()
In [26]:
data_chain = (data.groupby('category')
                  .agg(chain=('chain', 'sum'),
                       total=('chain', 'count'))
             )
data_chain['part'] = (data_chain['chain'] / data_chain['total'] * 100).round(2)
data_chain.sort_values(by='part', ascending=False)
Out[26]:
chain total part
category
булочная 157 256 61.33
пиццерия 330 633 52.13
кофейня 720 1413 50.96
быстрое питание 232 603 38.47
ресторан 730 2041 35.77
кафе 779 2377 32.77
столовая 88 315 27.94
бар,паб 169 765 22.09

Вывод: \ По данным есть большое разделение на сетевые (38.1%) и несетевые (61.9%) заведения по категориям.\ Лидеры по доле сетей:

  • 61% - булочная;
  • около 50% - кофейня и пиццерия;
  • около 40% - быстрое питание и ресторан.

Лидеры по количеству сетевых заведений: кафе (779), ресторан (730), кофейня (720).

по названиям заведений и ТОР-15 популярных сетей¶

Сгруппирую данные по названиям заведений и найду топ-15 популярных сетей в Москве.

In [27]:
data_top15 = (data[data['chain'] == 1]
              .groupby(['name', 'category'])
              .agg(count_name=('name', 'count'))
              .sort_values(by='count_name', ascending=False)
              .reset_index().head(15)
              .sort_values(by='category')
             )
display(data_top15.sort_values(by='count_name', ascending=False))
name category count_name
0 шоколадница кофейня 119
1 домино'с пицца пиццерия 76
2 додо пицца пиццерия 74
3 one price coffee кофейня 71
4 яндекс лавка ресторан 69
5 cofix кофейня 65
6 prime ресторан 49
7 кофепорт кофейня 42
8 кулинарная лавка братьев караваевых кафе 39
9 теремок ресторан 36
10 cofefest кофейня 31
11 чайхана кафе 26
12 буханка булочная 25
13 drive café кафе 24
14 кофемания кофейня 22

В TOP-15 попали преимущественно сети, которые реализуют готовую продукцию (с витрины) или в их меню доминируют быстро приготавливаемые блюда / стритфуд. Основной напиток в них - кофе.

Построю визуализацию через круговую и столбчатую диаграммы.

In [28]:
# подготавливаю датасет для визуализации
list_category = data_top15.groupby('category')['count_name'].sum()

# установливаю размер и цвета
plt.figure(figsize=(10, 10))

colors_small = ['#4285f4', '#ea4335', '#fbbc05', '#34a853', '#8561c5']
colors_big = ['#679df6',
              '#ee685d', '#f07b71', '#f28e85',
              '#fbc936', '#fcd050', '#fcd669', '#fddd82', '#fde49b', '#fdeab4',
              '#5cb975', '#70c286',
              '#9475cc', '#a388d3', '#b39cdb']
# строю визуализацию
bigger = plt.pie(data_top15['count_name'],
                 labels=data_top15['name'],
                 colors=colors_big,
                 startangle=90, frame=True,
                 autopct='%1.f%%', pctdistance=0.85
                )
smaller = plt.pie(list_category, colors=colors_small,
                  radius=0.70, startangle=90,
                  autopct='%1.f%%', pctdistance=0.75
                  )
centre_circle = plt.Circle((0, 0), 0.4, color='white', linewidth=0)
# настрою легенду
leg = plt.legend(loc = 'upper right', labels=list_category.index, fontsize=12)
leg.legendHandles[0].set_color('#4285f4')
leg.legendHandles[1].set_color('#ea4335')
leg.legendHandles[2].set_color('#fbbc05')
leg.legendHandles[3].set_color('#34a853')
leg.legendHandles[4].set_color('#8561c5')
# указываю название графика, вывожу на экран
fig = plt.gcf()
fig.gca().add_artist(centre_circle)
plt.title('TOP-15 популярных сетей в Москве по категориям', size=18)
plt.show()

# строю визуализацию
plt.figure(figsize=(14, 8))
ax = sns.barplot(
    x='count_name',
    y='name',
    hue='category',
    data=data_top15.sort_values(by='count_name', ascending=False),
    palette='Set1',
    dodge=False
)
# указываю названия графика и оси Х, вывожу на экран
plt.xlabel('Количество заведений', size=14)
plt.ylabel(' ')
plt.grid(axis='x')
ax.legend(title='Категория')
sns.despine(left=True)
plt.show()

Вывод: \ По результатам анализа TOP-15 самых популярных заведений вижу, что:

  • практически половина всех самых популярных заведений - кофейня (46%), такой отрыв может быть обусловлен огромной популярностью услуги кофе навынос;
  • по 20% доли составляют пиццерия и ресторан;
  • в список попала единственная булочная с долей в 3%.

Все эти сети заведений объединяет два фактора:

  • ключевой напиток в меню - кофе;
  • кухня работает с ориентацией на доставку и быструю скорость отдачи блюд.

по административным районам¶

Посмотрю, какие административные районы Москвы присутствуют в датасете, далее отображу общее количество заведений и количество заведений каждой категории по районам.

In [29]:
data_district = data.pivot_table(
    index='district_acronym', columns='category',
    values='name', aggfunc='count'
)
data_district['total_district'] = data_district.sum(axis=1)
data_district['total_district_part'] = (
    data_district['total_district'] /
    data_district['total_district']
    .sum() * 100).round(2)
data_district = data_district.sort_values(by='total_district', ascending=False).reset_index()
data_district
Out[29]:
category district_acronym бар,паб булочная быстрое питание кафе кофейня пиццерия ресторан столовая total_district total_district_part
0 ЦАО 364 50 87 464 428 113 670 66 2242 26.68
1 САО 68 39 58 235 193 77 188 41 899 10.70
2 ЮАО 68 25 85 264 131 73 202 44 892 10.62
3 СВАО 63 28 82 269 159 68 181 40 890 10.59
4 ЗАО 50 37 62 238 150 71 218 24 850 10.12
5 ВАО 53 25 71 272 105 72 160 40 798 9.50
6 ЮВАО 38 13 67 282 89 55 145 25 714 8.50
7 ЮЗАО 38 27 61 238 96 64 168 17 709 8.44
8 СЗАО 23 12 30 115 62 40 109 18 409 4.87

Сформировал сводную таблицу по районам с рабивкой ко категориям заведений, добавил столбцы о суммарном количестве и доле от общего числа. Попробую визуализировать через хитмэп и столбчатую диаграмму с накоплением.

In [30]:
# строю визуализацию
fig, ax = plt.subplots(figsize=(11, 9))
sns.heatmap(
    data_district.set_index('district_acronym')
    .drop(['total_district', 'total_district_part'], axis=1),
    cmap='Reds', center=175, annot=True,
    fmt="d", linewidths=.5, ax=ax)
# указываю название графика
ax.set_title('Количество заведений каждой категории по районам', size=16)
plt.ylabel(' ')
plt.xlabel(' ')
plt.setp(ax.get_xticklabels(), rotation=30)
plt.setp(ax.get_yticklabels(), rotation=0)
# вывожу на экран
fig.tight_layout()
plt.show()
In [31]:
# подготавливаю датасет для визуализации
data_district_bar = (
    data.groupby(['district_acronym', 'category'], as_index=False)
        .agg(counts=('name', 'count'))
        .merge(data_district[['district_acronym', 'total_district']],
                            how='left', on='district_acronym')
        .sort_values(by='total_district')
)
# строю визуализацию и вывожу на экран
fig = px.bar(data_district_bar, x='counts', y='district_acronym',
             color='category', height=500,
             labels={'category':'Тип заведения',
                     'counts' : 'Количество',
                     'district_acronym' : 'Округ'},
             title='Количество заведений каждой категории по районам')
fig.update_layout(title_x=0.5)
fig.show()

Вывод: \ По количеству заведений во всех категориях лидиром является Центральный административный округ (ЦАО). Его удельный вес от общего числа составил 26.68%, что в 2.5 раза более чем у ближайшего района. Такая цифра вполне закономерна, так как центр города является местом сосредоточения бизнеса и объектов туризма, что генерирует трафик для мест общественного питания. Он отличается и по структуре распределения заведений: только в нем доля ресторан больше, чем в других округах, где преобладают кафе. Схожи все районы только в том, что количество булочная самое небольшое из всех категорий. Меньше всего заведений находится в Северо-Западном округе.

распределения средних рейтингов по категориям заведений¶

Посчитаю и визуализирую через столбчатую диаграмму распределение средних рейтингов по категориям заведений.

In [32]:
# подготавливаю датасет для визуализации
data_avg_rat = (
    data
    .groupby('category').agg(avg_rating=('rating', 'mean'))
    .round(2).sort_values(by='avg_rating', ascending=False)
)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(data=data_avg_rat,
                 x=data_avg_rat.index,
                 y="avg_rating")
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
    ax.annotate(
        format(float(p.get_height()), ',').replace(',', ' '),
        (p.get_x() + p.get_width() / 2., p.get_height()),
        ha = 'center', va = 'center',
        xytext = (0, -12), color='white',
        textcoords = 'offset points', size=16)
# указываю названия графика и осей
plt.title('Средний рейтинг по категориям заведений', size=18)
plt.xlabel('Категория заведения', size=14)
plt.ylabel('Средний рейтинг', size=14)
# обозначаю среднее значение
plt.axhline(y=data_avg_rat['avg_rating'].mean(), color='black', linestyle='--')
# увеличиваю масштаб и вывожу на экран
plt.ylim(3.9, 4.45)
plt.grid(axis='y')
sns.despine(left=True)
plt.show()

print('Средний рейтинг среди всех категорий:', data_avg_rat['avg_rating'].mean().round(2))
Средний рейтинг среди всех категорий: 4.24

Вывод: \ Наиболее высокие оценки получает категория бар.паб, а самые низкие - заведения быстрое питание. Стоит отметить, что средний рейтинг в большинстве категорий находятся в близком к друг другу значениях. Западения наблюдаются только в кафе и быстрое питание.

по среднему рейтингу заведений по районам¶

Построю фоновую картограмму для более наглядной визуализации среднего рейтинга заведений по районам b отмечу все ззаведения на карте.

In [33]:
category_avg_rat = (
    data.groupby('district', as_index=False)['rating']
        .agg('mean').round(2)
)
category_avg_rat.sort_values(by='rating', ascending=False)
Out[33]:
district rating
5 Центральный административный округ 4.38
2 Северный административный округ 4.24
4 Северо-Западный административный округ 4.21
1 Западный административный округ 4.18
8 Южный административный округ 4.18
0 Восточный административный округ 4.17
7 Юго-Западный административный округ 4.17
3 Северо-Восточный административный округ 4.15
6 Юго-Восточный административный округ 4.10
In [34]:
m = Map(location=[55.751244, 37.618423],
        zoom_start=10)
# создаю фоновую картограмму и добавляю ее на карту
choropleth = Choropleth(
    geo_data=geo_json,
    data=category_avg_rat,
    columns=['district', 'rating'],
    key_on='feature.name',
    fill_color='YlGnBu',
    fill_opacity=0.8,
    legend_name='Средний рейтинг заведений по районам',
).add_to(m)
# создаю маркеры по названиям районов
choropleth.geojson.add_child(
    folium.features.GeoJsonTooltip(
        fields = ['ref'], aliases = ['Район:'],
        labels = True, localize = True, sticky = False)
)
# вывожу карту
m
Out[34]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Построю тепловую карту с распределением внутри районов.

In [35]:
m = Map(location=[55.751244, 37.618423], zoom_start=10)
m.add_child(plugins.HeatMap(data[['lat','lng']], radius=14))
Out[35]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод: \ Центральный административный округ лидирует не только по количеству, но и по среднему рейтингу заведений. Второе место обоих расчетов занимает Северный административный округ. На 3-е место списка попал район с наименьшей концентрацией мест общественного питания - Северо-Западный административный округ.

TOP-15 улиц по количеству заведений¶

Выведу TOP-15 улиц по количеству заведений на них и развибкой по категориям. Далее проиллюстрирую через интерактивную столбчатую диаграмму.

In [36]:
total_counts_str = data['street'].value_counts().reset_index()
total_counts_str.columns = ['street', 'counts_per_str']
top_str_list = total_counts_str.nlargest(15, columns='counts_per_str')
top_str_list
Out[36]:
street counts_per_str
0 проспект Мира 183
1 Профсоюзная улица 122
2 проспект Вернадского 108
3 Ленинский проспект 107
4 Ленинградский проспект 95
5 Дмитровское шоссе 88
6 Каширское шоссе 77
7 Варшавское шоссе 76
8 Ленинградское шоссе 70
9 МКАД 65
10 Люблинская улица 60
11 улица Вавилова 55
12 Кутузовский проспект 54
13 улица Миклухо-Маклая 49
14 Пятницкая улица 48
In [37]:
# подготавливаю датасет для визуализации
top_str = top_str_list['street']
data_top15_str = (
    data.query("street in @top_str")
        .groupby(['street', 'category'], as_index=False)
        .agg(cat_per_str=('category', 'count'))
)   
data_top15_str = (
    data_top15_str.merge(top_str_list, how='left', on='street')
                  .sort_values(by='counts_per_str')
)
# строю визуализацию и вывожу на экран
fig = px.bar(data_top15_str, x='cat_per_str', y='street',
             color='category', height=500,
             labels={'category':'Тип заведения',
                     'cat_per_str' : 'Количество',
                     'street' : 'Улица'},
             title='TOP-15 улиц по количеству заведений и их категории')
fig.update_layout(title_x=0.5)
fig.show()
# сводная таблица по долям
display((top_str_list['counts_per_str'] / data['street'].count() * 100).round(2).to_frame().T)
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
counts_per_str 2.18 1.45 1.29 1.27 1.13 1.05 0.92 0.9 0.83 0.77 0.71 0.65 0.64 0.58 0.57

Вывод: \ По данным графика и сводной таблицы видно:

  • больше всего заведений на проспекте Мира, улице Профсоюзной, проспекте Вернадского и Ленинском проспекте;
  • самые популярные категории заведений кафе, кофейня и ресторан;
  • разница между улицей-лидером по количеству заведений и 15 строчкой - более 3 раз.
  • по типу в список вошли в равных долях: проспект, улица, шоссе. Все участники рейтинга имеют протяженную длину и высокую транспортную пропускную способность.

по улицам, на которых находится только один объект общепита¶

Найду улицы на которых только одно заведение общественного питания и визуализирую через столбчатую диаграмму.

In [38]:
# подготавливаю датасет для визуализации
one_count_str = total_counts_str[total_counts_str['counts_per_str'] == 1]['street']
data_one_count_str = (
    data.query("street in @one_count_str")
        .groupby('category')
        .agg(total_counts=('category', 'count'))
        .sort_values(by='total_counts', ascending=False)
)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
    x=data_one_count_str.index,
    y="total_counts",
    data=data_one_count_str
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
    ax.annotate(
        format(int(p.get_height()), ',').replace(',', ' '),
        (p.get_x() + p.get_width() / 2., p.get_height()),
        ha = 'center', va = 'center',
        xytext = (0, -10), color='white',
        textcoords = 'offset points', size=12)
# указываю названия графика и оси Х
plt.title('Улицы с одним заведением по категориям', size=18)
plt.xlabel('Категория заведения', size=14)
# вывожу на экран
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
# сопровождаю график сводной инофрмацией
print('Количество улиц с одним заведением:', len(one_count_str))
print('Доля улиц с одним заведением в датасете: {:.2%}'
      .format(len(one_count_str) / data['street'].nunique()))
print('Доля каждого типа заведений среди улиц с одним объектом общепита:')
(data_one_count_str['total_counts'] / data_one_count_str['total_counts'].sum() * 100).round(2).to_frame().T
Количество улиц с одним заведением: 458
Доля улиц с одним заведением в датасете: 31.63%
Доля каждого типа заведений среди улиц с одним объектом общепита:
Out[38]:
category кафе ресторан кофейня бар,паб столовая быстрое питание пиццерия булочная
total_counts 34.93 20.31 18.34 8.52 7.86 5.02 3.28 1.75
In [39]:
data_distr_one_count_str = (
    data.query('street in @one_count_str')
        .groupby('district_acronym')
        .agg(counts=('name', 'count'))
        .sort_values(by='counts', ascending=False)
)
data_distr_one_count_str['rate'] = (
    data_distr_one_count_str['counts'] /
    data_distr_one_count_str['counts'].sum() * 100).round(2)

# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
    x=data_distr_one_count_str.index,
    y='counts',
    data=data_distr_one_count_str
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
    ax.annotate(
        format(int(p.get_height()), ',').replace(',', ' '),
        (p.get_x() + p.get_width() / 2., p.get_height()),
        ha = 'center', va = 'center',
        xytext = (0, -10), color='white',
        textcoords = 'offset points', size=12)
# указываю названия графика и оси Х
plt.title('Улицы с одним заведением по категориям', size=18)
plt.xlabel('Округ', size=14)
# вывожу на экран
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
data_distr_one_count_str.T
Out[39]:
district_acronym ЦАО СВАО ВАО САО ЮАО ЮВАО ЗАО СЗАО ЮЗАО
counts 145.00 55.00 52.00 52.00 43.00 39.00 35.00 19.00 18.00
rate 31.66 12.01 11.35 11.35 9.39 8.52 7.64 4.15 3.93

Вывод: \ В датасете преставлено 458 улиц с одним заведением общественного питания, что составляет более 30% из всех уникальных значений. Тип заведения которое чаще других встречается - кафе (160, 34.93%), второе и третье места заняли улицы где есть по одному заведению ресторан или кофейня - в районе 20%. Самая не многочисленная категория не набирает и 2% - булочная. Более 31% из всех улиц расположились в Центральном административном округе - в этой части города большинство улиц имеют небольшую протяженность, так как формировались столетия назад.

по медиане средних чеков по районам¶

Посчитаю медиану средних чеков для каждого района и построю фоновую картограмму (хороплет).

In [40]:
data_median_bill = (
    data.groupby('district', as_index=False)
        .agg(median_bill=('middle_avg_bill', 'median'))
        .sort_values(by='median_bill', ascending=False)
)
data_median_bill
Out[40]:
district median_bill
1 Западный административный округ 1000.0
5 Центральный административный округ 1000.0
4 Северо-Западный административный округ 700.0
2 Северный административный округ 650.0
7 Юго-Западный административный округ 600.0
0 Восточный административный округ 575.0
3 Северо-Восточный административный округ 500.0
8 Южный административный округ 500.0
6 Юго-Восточный административный округ 450.0
In [41]:
# применяю функцию для визуализации
fig = choropleth_mapbox(
    df=data_median_bill,
    color='median_bill',
    locations='district',
    geojson=geo_json,
    labels={'district' : 'Район',
            'median_bill' : 'Медианный чек'}
)
fig.show()
# создам сводную таблицу
display(
    data.pivot_table(index='district', columns='category',
                     values='middle_avg_bill', aggfunc='median')
        .reset_index()
        .merge(data_median_bill, how='left', on='district')
        .sort_values(by='median_bill', ascending=False)
)
district бар,паб булочная быстрое питание кафе кофейня пиццерия ресторан столовая median_bill
1 Западный административный округ 1250.0 600.0 367.5 625.0 600.0 700.0 1300.0 300.0 1000.0
5 Центральный административный округ 1250.0 962.5 450.0 700.0 500.0 1000.0 1250.0 300.0 1000.0
4 Северо-Западный административный округ 1000.0 200.0 275.0 650.0 325.0 549.5 1250.0 300.0 700.0
2 Северный административный округ 1250.0 625.0 300.0 550.0 325.0 650.0 1187.5 300.0 650.0
7 Юго-Западный административный округ 1000.0 500.0 375.0 450.0 375.0 500.0 1050.0 305.0 600.0
0 Восточный административный округ 1200.0 300.0 375.0 450.0 400.0 500.0 1000.0 300.0 575.0
3 Северо-Восточный административный округ 900.0 500.0 425.0 475.0 325.0 500.0 837.5 275.0 500.0
8 Южный административный округ 1175.0 437.5 400.0 600.0 387.5 500.0 975.0 282.5 500.0
6 Юго-Восточный административный округ 925.0 375.0 300.0 400.0 250.0 500.0 925.0 275.0 450.0
In [42]:
m = folium.Map([55.751244, 37.618423], zoom_start=10)
heatmap_data = data[['lat','lng', 'middle_avg_bill']].copy().dropna() # без удаления пустых значений heatmap не сработает
heatmap = heatmap_data[['lat','lng', 'middle_avg_bill']] 
HeatMap(data=heatmap, radius=14).add_to(m)
m
Out[42]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод: \ Самый высокий медианный чек в Центральном и Западном административных округах - 1000 рублей. На последнем месте расположился Юго-Восточный - 450 рублей. Если сравнить эти показатели с разбивкой по категория заведений, то наименьшая разница в столовая - не более 10% среди всех районов, в булочная наоборот самая высокий разброс почти 500%. Центральный административный округ почти во всех категориях имеет наивысший средний чек, кроме ресторан и кафе.

взаимосвязи по часам работы заведений¶

Исследую часы работы заведений и их зависимость от категории заведения и расположения. Визуализирую данные по категориям заведений через столбчатую диаграмму, а их расположение фоновой картограммой (хороплет).

In [43]:
# подготавливаю датасет для визуализации
data_around_the_clock = (
    data.query('is_24_7 == True')
        .groupby('category', as_index=False)
        .agg(around_the_clock=('is_24_7', 'count'))
        .sort_values(by='around_the_clock', ascending=False)
)
# строю визуализацию и вывожу на экран
fig = px.bar(data_around_the_clock, x='around_the_clock', y='category',
             color='category', height=500,
             labels={'category':'Тип заведения',
                     'around_the_clock' : 'Количество'},
             title='Количество круглосуточных заведений по категориям')
fig.update_layout(title_x=0.5)
fig.show()
print('Доля круглосуточных заведений от общего числа: {:.2%}'.format(data['is_24_7'].sum() / len(data)))
data_around_the_clock
Доля круглосуточных заведений от общего числа: 8.69%
Out[43]:
category around_the_clock
3 кафе 267
2 быстрое питание 150
6 ресторан 135
4 кофейня 59
0 бар,паб 52
5 пиццерия 31
1 булочная 24
7 столовая 12

Больше всего работают круглосуточно кафе - 267, второе и третье место с небольшой разницей делят быстрое питание и ресторан. В нижней части списка оказались - пиццерия, булочная, столовая - ассортимент их продукции пользуется высоким спросом в течении дня.

In [44]:
# применяю функцию для визуализации
fig = choropleth_mapbox(
    df=(
        data.query('is_24_7 == True')
        .groupby('district', as_index=False)
        .agg(around_the_clock=('is_24_7', 'count'))
    ),
    color='around_the_clock',
    locations='district',
    geojson=geo_json,
    labels={'district' : 'Район',
            'around_the_clock' : 'Заведения 24/7'},
    range_color=(40, 140)
)
fig.show()
# создаю сводную таблицу
display(
    data.pivot_table(index='district', columns='category',
                     values='is_24_7', aggfunc='sum')
        .reset_index()
        .merge(data.groupby('district', as_index=False)['is_24_7'].sum(), how='left', on='district')
        .sort_values(by='is_24_7', ascending=False)
)
district бар,паб булочная быстрое питание кафе кофейня пиццерия ресторан столовая is_24_7
5 Центральный административный округ 29 1 14 30 26 2 26 3 131
0 Восточный административный округ 1 6 21 33 5 8 21 2 97
6 Юго-Восточный административный округ 5 1 15 48 1 9 13 1 93
3 Северо-Восточный административный округ 4 4 17 31 3 3 11 2 75
8 Южный административный округ 4 3 24 26 1 4 13 0 75
7 Юго-Западный административный округ 0 1 20 34 7 0 10 1 73
1 Западный административный округ 5 0 18 25 9 2 12 1 72
2 Северный административный округ 4 5 14 28 5 2 11 2 71
4 Северо-Западный административный округ 0 3 7 12 2 1 18 0 43

Вывод: \ Центральный административный округ и по этому показателю вышел в уверенные лидеры, в 4 из 8 категорий у него перевес, причем в бар.паб и ресторан имеет доминирующее число. Восточный и Юго-Восточный административные округа заняли 2 и 3 места с почти равными результатами. Замкнул рейтинг Северо-Западный район, а оставшиеся административные округа расположились с максимально близким интервалом между собой.

распределения заведений с плохим рейтингом¶

Исследую особенности заведений с плохими рейтингами, средние чеки в таких местах и распределение по категориям. Для отображения воспользуюсь столбчатыми диаграммами.

In [45]:
data['rating'].describe().to_frame().T
Out[45]:
count mean std min 25% 50% 75% max
rating 8403.0 4.229894 0.470426 1.0 4.1 4.3 4.4 5.0

Проверил сводные значения рейтингов в датасете. Средний рейтинг составил 4.23, а медианный - 4.3. Такой поазатель можно считать достаточно высоким и хорошим, учитывая количество заведений. В качестве ориентира для определения границы плохой-хороший рейтинг возьму 25 перцентиль со значением 4.1.

In [46]:
# подготавливаю датасет для визуализации
data['bad_good_rating'] = (
    data['rating'].apply(lambda x: 'good' if x >= 4.1 else 'bad')
)
bad_good_catering = (
    data.groupby(['category', 'bad_good_rating'], as_index=False)
        .agg(counts=('bad_good_rating','count'))
        .merge(data_category, how='left',
               left_on='category', right_on=data_category.index)
)
bad_good_catering['total_counts'] = (bad_good_catering['counts'] / bad_good_catering['total_counts'] * 100).round(2)
# строю визуализацию и вывожу на экран
fig = px.bar(bad_good_catering.sort_values(by='total_counts'),
             x='total_counts', y='category', text='counts',
             color='bad_good_rating', height=400,
             labels={'category':'Тип заведения',
                     'total_counts' : 'Доля',
                     'bad_good_rating' : 'Тип рейтинга',
                     'counts' : 'Количество'},
             title='Заведения с плохими и хорошими рейтингами по категориям')
fig.update_traces(texttemplate='%{text:.d}', textposition='inside')
fig.update_layout(title_x=0.5)
fig.show()

Если смотреть на количество заведений с плохим рейтингом, лидером является категория кафе (717 заведений, 30.16%). По удельному весу в группе хуже всех ситуация в быстрое питание (222 заведения, 36.82%). наименьшие шанс попасть в заведение с плохим рейтингом при посещении пиццерия или бар.паб.

In [47]:
# подготавливаю датасет для визуализации
bad_rating_mean_bills = (
    data.query('bad_good_rating == "bad"')
        .groupby(['category', 'district_acronym'], as_index=False)
        .agg(mean_bills=('middle_avg_bill','mean'))
        .sort_values(by='mean_bills', ascending=False)
)
# строю визуализацию и вывожу на экран
fig = px.bar(bad_rating_mean_bills, x='mean_bills', y='district_acronym',
             color='category', height=500,
             labels={'category':'Тип заведения',
                     'mean_bills' : 'Средний чек',
                     'district_acronym' : 'Район'},
             title='Средний чек в заведениях с плохим рейтингом по районам и категориям')
fig.show()
In [48]:
# строю визуализацию
plt.figure(figsize=(14, 8))
ax = sns.barplot(
    x='district_acronym',
    y='mean_bills',
    hue='category',
    data=bad_rating_mean_bills,
    palette='Set1'
)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Средний чек в заведениях с плохим рейтингом по районам и категориям', size=18)
plt.xlabel('Районы', size=14)
plt.ylabel('Количество заведений', size=14)
plt.grid(axis='y')
ax.legend(title='Категория')
sns.despine(left=True)
plt.show()

Вывод: \ Самый высокий чек в заведении с рейтингом менее 4.1 будет при посещении категории быстрое питание причем не в Центральном районе Москвы, а в Восточном административном округе - 1 612.25 рублей. С разницей чуть больше 100 руб. составит посещение пиццерия на Юго-Востоке столицы. В ЦАО по данному критерию нет категорий заведений, которые опережают все районы.

При выборе булочная в ЦАО и ЗАО посетителю можно смело выбирать любую, так же как и бар.паб на Юго-Западе, Юго-Востоке и Северо-Западе Москвы. По этим категориям в указанных районах не попало ни одно заведение с низким рейтингом.

Вывод по анализу данных¶

По категории заведений: самое распространенное заведение - кафе. Самое редкое - булочная.\ По количеству посадочных мест: медианное значение для всех групп заведений - 75 мест. Максимальные по уровню значения у ресторан (86), кофейня (80) и бар.паб (82), а минимальное - в булочной(50).\ По доле сетевых и несетевых заведений: все заведения разделились на сетевые (38.1%) и несетевые (61.9%). Наибольшая доля в группе сетевых - булочная (61%), затем кофейня и пиццерия (около 50%). В количественном выражении пропорция смещается: кафе (779), ресторан (730), кофейня (720).\ По названиям заведений: в ТОР-15 вошли все заведения из представителей сетей. Практически половина из них сосредоточена в сфере кофейня(46%). На последнем месте находится единственный представитель булочная с долей 3%.\ По административным районам: лидер - Центральный административный округ с долей 26.68%, опередил ближайший район более, чем в 2.5 раза. Меньше всего мест общественного питания на Северо-Западе столицы - менее 5%.\ По рейтингу категорий заведений: наиболее высокие оценки получает категория бар.паб (4.39), а самые низкие - заведения быстрое питание (4.05).\ По рейтингу заведений по районам: первое место - Центральный административный округ (4.38). Замкнул рейтинг Юго-Восточный административный округ (4.10).\ По наивысшей концентрации заведений в рамках 1 улицы: больше всего заведений на проспекте Мира, улице Профсоюзной, проспекте Вернадского и Ленинском проспекте. Чаще всего - это кафе, кофейня и ресторан.\ По улицам, где располагается только 1 заведение: на 30% улиц общественное питание представлено одним заведением. Чаще всего - это кафе (35%), ресторан или кофейня (около 20%). Реже всего на них можно встретить булочная (менее 2%).\ По медианному значению средних чеков: самый высокий медианный чек в Центральном и Западном административных округах - 1000 рублей. На последнем месте расположился Юго-Восточный - 450 рублей. Наивысшая волатильность по районам в категории булочная - от 200 до 962.5 рублей.\ По режиму работы: легче всего найти круглосуточное заведение в Центральном административном округе, это скорее всего будет кофейня, ресторан или бар.паб. В остальных районах этими заведениями могут быть кафе или быстрое питание.\ По распределению заведений с плохим рейтингом: плохим рейтингом считаю менее 4.1, чаще всего такой рейтинг можно встретить в категории кафе (717 заведений, 30.16%), но в процентном соотношении - быстрое питание (222 заведения, 36.82%). Чек в таких местах имеет широкий диапозон: от 200 руб. в булочная на Северо-Западе до 1 612.25 в быстрое питание в Восточном административном округе. При выборе булочная в ЦАО и ЗАО посетителю можно смело выбирать любую, так же как и бар.паб на Юго-Западе, Юго-Востоке и Северо-Западе Москвы. По этим категориям в указанных районах не попало ни одно заведение с низким рейтингом.

Детализация исследования¶

Для понимания того, осуществима ли мечта клиентов по открытию кофейни аналогичной «Central Perk», проведу ряд исследований по категории кофейня и визуализирую результаты.

In [49]:
# создам сводную таблицу
data_coffee_house = data.query('category == "кофейня"')
pvt_data_coffee_house = (
    data_coffee_house
    .groupby('district_acronym', as_index=False)
    .agg(total_counts=('name', 'count'),
         counts_chain=('chain', 'sum'),
         around_the_clock=('is_24_7', 'sum'),
         avg_rating=('rating', 'mean'),
         coffe_price=('middle_coffee_cup', 'mean'),
         middle_avg_bill=('middle_avg_bill', 'mean'),
         avg_seats=('seats', 'median')
         ).round(2)
    .sort_values(by='total_counts', ascending=False))

display(pvt_data_coffee_house)
print('Количество кофеен в Москве:', len(data_coffee_house))
print('Количество несетевых кофеен в Москве:', len(data_coffee_house) - data_coffee_house['chain'].sum())
print('Количество круглосуточных кофеен в Москве:', data_coffee_house['is_24_7'].sum())
print('Средний рейтинг кофеен в Москве:', data_coffee_house['rating'].mean().round(2))
print('Средний цена чашки капучино в Москве:', data_coffee_house['middle_coffee_cup'].mean().round())
print('Средний чек в кофейне в Москве:', data_coffee_house['middle_avg_bill'].mean().round())
print('Медианный показатель посадочных мест в кофейне в Москве:', data_coffee_house['seats'].median())
district_acronym total_counts counts_chain around_the_clock avg_rating coffe_price middle_avg_bill avg_seats
5 ЦАО 428 221 26 4.34 187.52 794.76 86.0
2 САО 193 97 5 4.29 165.79 495.74 66.0
3 СВАО 159 79 3 4.22 165.33 433.16 75.0
1 ЗАО 150 93 9 4.20 189.94 694.44 96.0
6 ЮАО 131 66 1 4.23 158.49 504.78 80.0
0 ВАО 105 51 5 4.28 174.02 486.11 55.0
8 ЮЗАО 96 50 7 4.28 184.18 381.82 64.5
7 ЮВАО 89 29 1 4.23 151.09 263.00 50.0
4 СЗАО 62 34 2 4.33 165.52 440.64 87.5
Количество кофеен в Москве: 1413
Количество несетевых кофеен в Москве: 693
Количество круглосуточных кофеен в Москве: 59
Средний рейтинг кофеен в Москве: 4.28
Средний цена чашки капучино в Москве: 175.0
Средний чек в кофейне в Москве: 614.0
Медианный показатель посадочных мест в кофейне в Москве: 80.0

Из этих данных можно сделать вывод, что:

  • средний рейтинг кофеен на всей территории Москвы превышает установленную планку в 4.1;
  • средняя цена чашки капучино составляет 175 рублей, но в 6 районах из 9 она ниже. Только в Центральном, Западном и Юго-Западном цена превышает среднее значение;
  • сетевые и несетевые кофейни распределились практически поровну. Сетевые кофейни чаще встречаются в Центральном, Западном, Юго-Западном и Северо-Западном административных районах;
  • за поход в кофейню в среднем нужно будет заплатить 614 рублей, дороже на 80 рублей поход в Западном и на 180 в Центральном округах;
  • медианой количества мест в заведении является 80. Есть районы с большими размерами, такие как Центральный, Западный и Северо-Западный округа, но также и сильно меньше, к примеру, Юго-Восточный с показателем 50 мест.
In [50]:
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
    x='district_acronym',
    y='total_counts',
    data=pvt_data_coffee_house
)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
    ax.annotate(
        format(int(p.get_height()), ',').replace(',', ' '),
        (p.get_x() + p.get_width() / 2., p.get_height()),
        ha = 'center', va = 'center',
        xytext = (0, -14), color='white',
        textcoords = 'offset points', size=16)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Количество кофеен по округам', size=18)
plt.xlabel('Административный округ', size=14)
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
In [51]:
# строю визуализацию
fig = px.scatter_mapbox(data_coffee_house, lat='lat', lon='lng',
                        color='chain', color_continuous_scale=["red", "blue"],
                        zoom=9, hover_name='name', 
                        hover_data=['rating', 'middle_coffee_cup', 'middle_avg_bill', 'seats'],
                        labels={'lat' : 'широта', 'lng' : 'долгота',
                                'rating' : 'Рейтинг',
                                'chain' : 'Заведение относится к сети',
                                'middle_coffee_cup' : 'Стоимость чашки капучино',
                                'middle_avg_bill' : 'Средний чек',
                                'seats' : 'Количество мест'
                                })
fig.update_layout(
    margin={"r": 0, "t": 0, "l": 0, "b": 0},
    mapbox_style="carto-positron",
    height=500, 
)

fig.show()

География распространения кофеен не равномерна: в центре концентрация очень густая, по районам ситуация отличается между собой. Поскольку заказчик не боится конкуренции в интересующей его категории заведений общественного питания, стоит присмотреться к ТОР-15 улиц по количеству заведений всех категорий.

Оставлю на этих улицах только категорию кофейня и выведу карту. Дополнительно сформирую по ряду критериев сводную таблицу.

In [52]:
# подготавливаю датасет для визуализации
data_coffee_house_top_str = (
    data_coffee_house.query('street in @top_str')
)
# строю визуализацию
fig = px.scatter_mapbox(data_coffee_house_top_str, lat='lat', lon='lng',
                        color='chain', color_continuous_scale=["red", "blue"],
                        zoom=9, hover_name='name', 
                        hover_data=['rating', 'middle_coffee_cup', 'middle_avg_bill', 'seats'],
                        labels={'lat' : 'широта', 'lng' : 'долгота',
                                'rating' : 'Рейтинг',
                                'chain' : 'Заведение относится к сети',
                                'middle_coffee_cup' : 'Стоимость чашки капучино',
                                'middle_avg_bill' : 'Средний чек',
                                'seats' : 'Количество мест'
                                })
fig.update_layout(
    margin={"r": 0, "t": 0, "l": 0, "b": 0},
    mapbox_style="carto-positron",
    height=500, 
)

fig.show()
In [53]:
# создам сводную таблицу
pvt_data_ch = (
    data_coffee_house_top_str
    .groupby('street', as_index=False)
    .agg(coffee_house=('name', 'count'),
         chain=('chain', 'sum'),
         coffe_price=('middle_coffee_cup', 'mean'),
         middle_avg_bill=('middle_avg_bill', 'mean'),
         avg_seats=('seats', 'median'))
)

pvt_data_ch = (
    pvt_data_ch.merge(top_str_list, how='left', on='street')
)
pvt_data_ch['coffe_house_rate'] = (
    pvt_data_ch['coffee_house'] /
    pvt_data_ch['counts_per_str'] * 100
)
pvt_data_ch['unchain_rate'] = (
    (pvt_data_ch['chain'] /
     pvt_data_ch['coffee_house'] - 1) * 100).abs()
pvt_data_ch = (
    pvt_data_ch.drop('chain', axis=1)
               .sort_values(by='unchain_rate', ascending=False)
               .round(2)
)
display(pvt_data_ch)
print('Средний цена чашки капучино на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
      .format(pvt_data_ch['coffe_price'].mean()))
print('Средний чек на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
      .format(pvt_data_ch['middle_avg_bill'].mean()))
print('Медианный показатель посадочных мест на TOP-15 улиц в кофейнях в Москве:', pvt_data_ch['avg_seats'].median())
print('Средний процент кофеен на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
      .format(pvt_data_ch['coffe_house_rate'].mean()))
print('Средний процент несетевых кофеен на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
      .format(pvt_data_ch['unchain_rate'].mean()))
street coffee_house coffe_price middle_avg_bill avg_seats counts_per_str coffe_house_rate unchain_rate
6 Ленинский проспект 23 224.33 416.67 98.0 107 21.50 65.22
4 Ленинградский проспект 25 187.55 350.00 150.0 95 26.32 56.00
0 Варшавское шоссе 14 180.25 716.67 180.0 76 18.42 50.00
10 Пятницкая улица 6 225.00 140.00 280.0 48 12.50 50.00
7 Люблинская улица 11 157.00 50.00 54.0 60 18.33 45.45
12 проспект Мира 36 187.89 770.00 111.5 183 19.67 41.67
9 Профсоюзная улица 18 161.83 NaN 80.5 122 14.75 33.33
2 Каширское шоссе 16 130.80 625.00 176.0 77 20.78 31.25
11 проспект Вернадского 16 188.75 225.00 160.0 108 14.81 31.25
13 улица Вавилова 10 160.00 NaN 320.0 55 18.18 30.00
1 Дмитровское шоссе 11 194.40 300.00 120.0 88 12.50 27.27
3 Кутузовский проспект 13 201.00 1150.00 96.0 54 24.07 23.08
5 Ленинградское шоссе 13 195.00 875.00 96.0 70 18.57 15.38
8 МКАД 4 NaN NaN NaN 65 6.15 0.00
14 улица Миклухо-Маклая 4 157.00 NaN 120.0 49 8.16 0.00
Средний цена чашки капучино на TOP-15 улиц по количеству заведений в Москве: 182.20
Средний чек на TOP-15 улиц по количеству заведений в Москве: 510.76
Медианный показатель посадочных мест на TOP-15 улиц в кофейнях в Москве: 120.0
Средний процент кофеен на TOP-15 улиц по количеству заведений в Москве: 16.98
Средний процент несетевых кофеен на TOP-15 улиц по количеству заведений в Москве: 33.33

Из этих данных можно сделать вывод, что:

  • средняя цена чашки капучино на выбранных улицах составляет 182.20 рублей, что выше среднего по Москве;
  • средний чек при этом снизился более чем на 100 рублей до 510.76;
  • медианная вместимость в заведениях на TOP-15 улиц является 120. Этот показатель по сравнению со среднемосковским вырос на 50%;
  • сделал расчет 2 показателей: среднего процента кофеен из общего числа заведений на улицах и средний процент несетевых кофеен из всех на каждой из улиц.

Последние показатели использую для визуализации через столбчатые диаграммы.

In [54]:
# построение визуализации
plt.figure(figsize=(14, 6))
ax = sns.barplot(data=pvt_data_ch.sort_values(by='coffe_house_rate', ascending=False),
                 x='coffe_house_rate',
                 y='street',
                 palette='Set1')
# укажу названия графика и осей
plt.title('Средний процент кофеен на TOP-15 улиц по количеству заведений', size=18)
plt.xlabel('Доля кофеен', size=14)
plt.ylabel(' ')
# увеличу масштаб и выведу на экран
plt.axvline(x=pvt_data_ch['coffe_house_rate'].mean(), color='black', linestyle='--')
plt.grid(axis='x')
sns.despine(left=True)
plt.show()

# построение визуализации
plt.figure(figsize=(14, 6))
ax = sns.barplot(data=pvt_data_ch.sort_values(by='unchain_rate', ascending=False),
                 x='unchain_rate',
                 y='street',
                 palette='Set1')
# укажу названия графика и осей
plt.title('Средний процент несетевых кофеен на TOP-15 улиц по количеству заведений', size=18)
plt.xlabel('Доля кофеен', size=14)
plt.ylabel(' ')
# увеличу масштаб и выведу на экран
plt.axvline(x=pvt_data_ch['unchain_rate'].mean(), color='black', linestyle='--')
plt.grid(axis='x')
sns.despine(left=True)
plt.show()
In [55]:
coffe_house_rate = pvt_data_ch['coffe_house_rate'].mean().round(2)
unchain_rate = pvt_data_ch['unchain_rate'].mean().round(2)
pvt_data_ch.query('coffe_house_rate <= @coffe_house_rate and unchain_rate <= @unchain_rate')
Out[55]:
street coffee_house coffe_price middle_avg_bill avg_seats counts_per_str coffe_house_rate unchain_rate
9 Профсоюзная улица 18 161.83 NaN 80.5 122 14.75 33.33
11 проспект Вернадского 16 188.75 225.0 160.0 108 14.81 31.25
1 Дмитровское шоссе 11 194.40 300.0 120.0 88 12.50 27.27
8 МКАД 4 NaN NaN NaN 65 6.15 0.00
14 улица Миклухо-Маклая 4 157.00 NaN 120.0 49 8.16 0.00

Подводя итог всех вычислений и расчетов выше, в выборку попадают 5 улиц, которые по среднему процента кофеен из общего числа заведений на улицу и среднему проценту несетевых кофеен из всех не превысили допустимый уровень наполненности категорией кофейня. Однако улицу МКАД исключу из рекомендуемых вариантов, поскольку длина улицы и количество заведений на ней несопоставимы. 3 из 4 оставшихся улиц располагаются в одном Юго-Западном административном округе (Профсоюзная улица, проспект Вернадского, улица Миклухо-Маклая), оставшееся Дмитровское шоссе в Северном округе. Эти районы стоит рассматривать для запуска нового проекта.

Оптимальные критерии для запуска проекта - кофейня в стиле «Central Perk»¶

Район размещения: Юго-Западный или Северный административные округа;\ Ориентировочная локация: Профсоюзная улица, проспект Вернадского, улица Миклухо-Маклая (ЮЗАО), Дмитровское шоссе (САО);\ Количество посадочных мест: от 80 до 120;\ Средний чек заведения: от 400 до 600 рублей;\ Стоимость чашки капучино: от 165 до 185 рублей;\ График работы: не круглосуточно;\ Рейтинг заведения: не ниже 4.3.

Презентация: Презентация Анализ рынка заведений общественного питания Москвы